Skip to content

fix(datadog): restore LCP/FCP reporting by keeping the initial_load view alive - #1642

Merged
dawsontoth merged 9 commits into
stagefrom
fix/rum-initial-view-vitals
Aug 25, 2026
Merged

fix(datadog): restore LCP/FCP reporting by keeping the initial_load view alive#1642
dawsontoth merged 9 commits into
stagefrom
fix/rum-initial-view-vitals

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Studio has reported no LCP or FCP since 2026-07-04 (#1570) because it called datadogRum.startView twice on boot, and the second call destroyed the only view that can carry paint metrics. This removes the redundant call, which also fixes every initial page load being attributed to the view name /.

Under trackViewsManually: true the RUM SDK stays stopped until the first startView, adopts that call's options as its single initial_load view, and turns every later call into a route_change view (preStartRum.ts tryStartRum, trackViews.ts startView). Only an initial_load view runs trackInitialViewMetrics, so it is the only view that can ever carry LCP or FCP. useDatadog and useOnRouteLoadTracker both called startView, so the initial view was ended microseconds after it began.

For the human reviewer

  1. Which of the two startView calls to delete. Kept useOnRouteLoadTracker's, deleted useDatadog's. The alternative — keep useDatadog's and gate the tracker to fire only on subsequent routes — restores vitals equally well but leaves every initial_load view named /, because useDatadog named views from window.location.pathname and Studio uses hash routing. Deleting useDatadog's call fixes both defects with one edit. Fully reversible; a change of mind costs one commit.

  2. The boot-redirect concern an earlier draft of this description left to you is now settled, and it splits in two. Measured against the real TanStack router in jsdom, not reasoned about:

    • Ordinary signed-out deep link — one view, no problem. authStore.getAllConnections() synthesizes { user: null, isLoading: false } for OverallAppSignIn whenever the Studio:PotentiallyAuthenticated record has no entry for it, so dashboardLayout.beforeLoad throws its redirect during the router's initial load and the root component never commits the deep-link location. Exactly one startView, named /sign-in/. datadogBootView.test.tsx now pins this.
    • Expired session — two views, and I am deliberately leaving it. With the flag present, getAllConnections() returns the record untouched and the key is simply absent, so beforeLoad short-circuits on auth && rather than on isLoading. The deep link renders, then auth resolves and AppRouted's router.invalidate() drives the redirect. The judgment: that second view is a genuine navigation, and RUM ending the current view on navigation is correct behaviour, not a bug — suppressing it would cost real route_change tracking to buy a metric. Note the window here is a network round trip, not the 0.3 ms of the bug being fixed, and this PR takes such a session from three views to two.

    The residual risk if you disagree: expired-session sessions may still under-report vitals. Say the word and I will extend this PR.

  3. RUM now starts on first route render rather than on App mount — and there is a narrow telemetry consequence. This follows from (1): the first startView is what starts the SDK, and the tracker lives in StudioCloud, the root route component. Gemini flagged this as a data-loss major on the theory that some routes render outside the cloud root; that is refutedrootRouteTree is rootRoute.addChildren([...]), so every route renders inside StudioCloud, including defaultNotFoundComponent and defaultErrorComponent. What remains is genuinely narrow: if the root component or the router itself fails catastrophically before rendering, no view ever starts and that session reports nothing, where previously useDatadog would already have started RUM from outside the router. If you want that closed, the clean way is useDatadog keeping a startView and the tracker calling setViewName() on its first run instead of startView — the SDK exposes it (rumPublicApi.ts:118) and it renames the initial view without ending it. I did not do it because it adds first-run state for a failure mode I cannot reproduce, but it is the strictly-better design if you judge the boot-error window worth it.

  4. StudioLocal does not call the tracker, so local Studio never starts a view. Pre-existing and correct — enabled is !import.meta.env.DEV && !isLocalStudio, so RUM is fully disabled there and no view would have been sent anyway. Flagged only because the asymmetry reads like an oversight in the diff.

Addressed from PR review: gemini-code-assist found the mocked useRouter returned a fresh object per render. That was not cosmetic — because the tracker's effect lists router in its deps, the effect re-fired on every render and the subsequent-navigation assertion was vacuous, holding even without location.href as a dependency. Fixed in ff3635b0 with a single stable router identity, plus an assertion that a no-op re-render produces no view; that assertion fails against the old mock, so it stays guarded.

Addressed from pre-push review: codex caught that the new AGENTS.md note credited getAllConnections() with reporting isLoading: true for a persisted session. It does not — that value comes from getConnectionById, a different API. Corrected in e53ca443; the two outcomes were right, the mechanism was not.

Declined, three rounds running: a nit that the diff's five comments narrate. I audited each against the zero-new-comments default and kept all five, because each is a constraint a future reader would otherwise delete along with the guard it protects — the datadog.ts comment marks the absence of a startView at the exact line someone would re-add one; the router-mock comment is why the mock is a single instance with a getter, which "simplifying" back to a literal makes the navigation tests vacuous again; datadog.test.tsx:41 is why the suite uses a dynamic import (a top-level one leaves enabled false and every assertion passes for nothing); :117 is why a re-render that changes nothing is not dead code; and datadogBootView.test.tsx:26 is why the real routes there are load-bearing. Overturn any of them cheaply if you disagree — the reasons are all one line.

Verified and closed, recorded here so you don't re-derive them. Both re-checked this round against the installed @datadog/browser-rum-core@7.8.0, not the published source:

  • gemini's child-before-parent effect-ordering concern (the tracker in a child firing startView before useDatadog's init) is safe. onReady(callback) { callback() }browser-core/cjs/boot/init.js:8, synchronous. A startView arriving before init is buffered into bufferApiCalls, recorded as firstStartViewCall, and adopted as initialViewOptions when tryStartRum() later succeeds — browser-rum-core/cjs/boot/preStartRum.js:121-133 and :33-39. This is the SDK's designed path for trackViewsManually, not a tolerated accident.
  • gemini's claim that datadogBootView.test.tsx fails to catch useDatadog reintroducing a boot startView is true of that file and irrelevant — the two-hook tree case is the first test in datadog.test.tsx, and it is the one that goes red under exactly that mutation. The two files guard different invariants on purpose.
  • gemini's second-round crash-safety minor — "any crash above the router tree loses all telemetry" — is the same window as item (3) above, independently rediscovered. No new information, but it does corroborate that (3) is the right thing to have disclosed rather than buried.
  • Not tested on purpose: the expired-session path from item (2). Reproducing it in a test means reimplementing AppRouted's context-and-invalidate() wiring in the harness, which tests the harness rather than the product. I measured it with a throwaway probe instead — two views, matching the AGENTS.md note — and shipped only the test whose reals are all production code.
  • Not taken: narrowing the tracker's effect deps from router to location.href — a pre-existing line this PR doesn't touch, with a hypothetical trigger, so it stayed out under YAGNI.

Verification

Route: live reproduction against the real SDK, plus a real-router regression test — the change is not observable through the e2e suite (RUM is disabled in dev and test builds, and no e2e spec touches it).

Served the shipped @datadog/browser-rum@7.8.0 bundle over HTTP and replicated Studio's boot sequence (init in a deferred callback, startView inside onReady, then a second startView in the same flush), with a beforeSend that captured each assembled event and returned false so nothing reached Datadog:

  • Two calls (current stage behaviour): the calls land at t=78.2ms and t=78.5ms — 0.3ms apart. The initial_load event ships with dom_complete: 79ms and no lcp, no fcp, then a route_change view takes over.
  • Production agrees: of 200 raw initial_load views over 7 days, dom_complete 17, fcp 2, lcp 0. All 880 initial_load views that week are named / — one facet bucket — while route_change views carry proper route names.

Regression tests. datadog.test.tsx (4 cases, mocked router): mounts both hooks in the production nesting and asserts exactly one startView; pins its name to the translated route; keeps an isolated useDatadog case to localise a failure; and asserts a further named view per subsequent navigation. datadogBootView.test.tsx (1 case, real router) boots the real rootRoute and the real dashboardLayout guard on a signed-out deep link and asserts one startView named /sign-in/ — this is the file that guards the invariant the whole fix rests on, that the tracker is still mounted at the root route.

All mutation-verified rather than merely green: re-adding the deleted block turns the tree-level and isolated cases red; dropping useOnRouteLoadTracker() from StudioCloud takes the boot test to 0 calls; and disabling the redirect guard moves its asserted name to the deep link, so the name assertion is load-bearing too.

Gates (Node 24.19.0, all exit 0, re-run after every review round): vitest run 323 files / 2626 passed, tsc -b, oxlint, dprint check, and pnpm test:e2e:docker (4 passed, 4 skipped — the skipped specs need roundtrip credentials). Script mapping: Studio has no test:unit:main/test:unit:resources/test:integration:all; the equivalents are test (vitest) and test:e2e:docker (Playwright).

No documentation PR. Nothing user-facing changes — this is internal telemetry wiring. The durable findings went to AGENTS.md instead, which is where the next agent to touch RUM will look.

Not proven, and the thing to watch post-merge: that the fix restores LCP/FCP end-to-end. Every browser surface available locally reports visibilityState: 'hidden', which emits zero paint and LCP entries, and trackFirstHidden would discard them anyway — so vitals read as absent whether the fix works or not. The cheap confirmation is @view.largest_contentful_paint coverage on initial_load views after this deploys: it should go from 0% to a non-trivial share. Do not close #1570 on merge — close it on that measurement, which is why this description says "Refs" and not "Fixes". Note #1405's baseline predates this and needs re-framing rather than just fresh data — its "/ view" was every deep-link entry conflated into one bucket.

Coverage caveat. The Harper domain adjudicator has failed on every round of this PR (exit-1, zero-byte log — a known failure on this machine), so no outside finding here was ever machine-adjudicated; I triaged them against the installed SDK and the route tree, which is why several are recorded as refuted/verified rather than fixed. Both Cursor lenses are structurally unavailable to this PR: cursor-review refuses any diff that touches AGENTS.md, and this one does. What did run independently is codex + gemini, both on the final code.

Complexity: medium

Review-Coverage: authored=claude; ran=gemini,codex; declined=cursor-grok,cursor-composer,domain; rounds=8 @ e53ca44

Human-Review-Need: 4 @ e53ca44

dawsontoth and others added 4 commits August 21, 2026 11:25
… again

Under `trackViewsManually` the RUM SDK stays stopped until the first
`startView`, adopts that call's options as its one `initial_load` view, and
turns every later call into a `route_change` view. Only an `initial_load` view
runs `trackInitialViewMetrics`, so it is the only view that can ever carry LCP
or FCP.

Studio called `startView` twice on boot — once in `useDatadog`, then again in
`useOnRouteLoadTracker` — so the initial view was ended microseconds later and
its paint metrics were thrown away. Measured against the real SDK bundle, the
two calls land 0.3ms apart and the initial_load event ships with dom_complete
but no lcp and no fcp, matching production exactly: of 200 initial_load views,
dom_complete 17, fcp 2, lcp 0.

Drop the `useDatadog` call and leave the single one to `useOnRouteLoadTracker`,
which mounts on the root route and so runs on every cloud route. That also
fixes the view name: `useDatadog` used `window.location.pathname`, which is
permanently `/` under the hash router, so all 880 initial_load views in a week
were named `/` regardless of the route actually loaded.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…trim comments

Cross-model review (cursor-composer) noted the #1570 guard rendered `useDatadog`
alone, so it only caught the specific regression of re-adding that call — a
second `startView` introduced anywhere else in the boot tree would leave it
green while production went back to zero vitals. Mount both hooks the way
production does (App → StudioCloud) and assert exactly one `startView`, which
is the invariant that actually matters; keep the isolated case to narrow a
failure to the hook that regressed.

Also pin the expected view name to the translated route so a revert to
pathname-based naming fails CI instead of silently restoring permanently-`/`
names, and trim the comments in both files to the one non-obvious SDK
constraint per the repo's zero-new-comments default (codex nit).

Both new assertions are mutation-verified: re-adding the deleted `startView`
turns the tree-level test and the isolated test red.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-2 review (gemini) noted the suite proved the boot case but nothing about
later navigations, so a regression that stopped emitting `route_change` views
would go unnoticed. Assert the tracker emits a further named view per href
change.

Comment trim per the repeated nit from both lenses: drop the issue-number
narration and the restated test rationale, keeping only the two non-obvious
constraints (the module-scope `enabled` read, and the production nesting the
boot test mirrors).

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The invariant is documented in AGENTS.md; the nesting is visible in the code.
Third repeat of the same review nit.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request resolves an issue where Datadog RUM's Core Web Vitals (LCP/FCP) were not being tracked due to multiple startView calls during boot. The initial startView call has been removed from useDatadog so that useOnRouteLoadTracker is the sole owner of the initial view. Documentation has been added to AGENTS.md to explain this behavior, and a new test suite has been introduced. The review feedback suggests stabilizing the mocked useRouter hook in the tests to prevent unnecessary effect re-runs caused by unstable object references.

Comment thread src/integrations/datadog/datadog.test.tsx Outdated
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 59.43% 7967 / 13405
🔵 Statements 59.9% 8544 / 14263
🔵 Functions 52.23% 2014 / 3856
🔵 Branches 53.34% 5723 / 10728
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/integrations/datadog/datadog.ts 53.48% 35.71% 87.5% 54.76% 18, 80-84, 92-125
Generated in workflow #1791 for commit e53ca44 by the Vitest Coverage Report Action

dawsontoth and others added 5 commits August 21, 2026 11:47
gemini-code-assist: the mock returned a fresh `useRouter()` object per render,
and the tracker's effect lists `router` in its deps — so the effect re-fired on
every render and the navigation assertions held even without `location.href` as
a dependency. The real `useRouter` returns a stable reference, so the mock was
also unfaithful.

Instantiate the router once with a getter for `state`, and assert that a
re-render which changes nothing produces no view. That assertion fails against
the old unstable mock, so the fix stays guarded.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… identities

The lesson from this PR's own escaped review finding: an unstable mocked
`useRouter` made an effect-counting test pass for the wrong reason, and would
have passed with the dependency removed entirely. Records the getter pattern
and the no-op-rerender assertion that catches it.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The existing suite mocks `@tanstack/react-router` entirely, so nothing proved
the invariant the fix actually rests on: that the tracker is mounted at the
root route and therefore runs on every cloud page load. If someone moves
`useOnRouteLoadTracker` into a narrower layout, RUM silently stops starting
any view — a no-data failure nothing alerts on.

Boots the real `rootRoute` and the real `dashboardLayout` guard on a
signed-out deep link and asserts exactly one `startView`, named `/sign-in/`.
Mutation-verified both ways: dropping the tracker from `StudioCloud` gives
0 calls, and disabling the redirect guard moves the name to the deep link.

It also settles a question the PR description had left to the reviewer — a
boot-time redirect does not re-fire the initial view, because the guard
resolves during the router's initial load and the root component never
commits the deep-link location.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whether a boot redirect costs the `initial_load` view turns entirely on
whether auth is known synchronously, which is not obvious from either file:
`beforeLoad` redirects only once `!isLoading && !user`, and
`getAllConnections()` reports `isLoading: false` up front unless the
`Studio:PotentiallyAuthenticated` record carries an `OverallAppSignIn` entry.
Ordinary signed-out deep link: one view. Expired session: two, a round trip
apart. Verified against the real router.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
codex (pre-push): the note credited `getAllConnections()` with reporting
`isLoading: true` for a persisted session. It does not — that value comes from
`getConnectionById`. `getAllConnections()` synthesizes an entry only when the
`Studio:PotentiallyAuthenticated` record lacks `OverallAppSignIn`; with the
entry present it returns the record untouched and the key is absent, so
`beforeLoad` short-circuits on `auth &&` rather than on `isLoading`. Same two
outcomes, right mechanism.

Also trims the narrating half of the new test's header comment, which both
legs flagged.

Refs #1570

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review August 24, 2026 17:31
@dawsontoth
dawsontoth requested a review from a team as a code owner August 24, 2026 17:31

@DavidCockerill DavidCockerill left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. No findings. The diagnosis is the valuable part and it is exactly right.

Verified nothing is lost by the deletion, which is the thing to check when removing a call that passed options: the deleted startView carried service: 'studio' and version: VITE_STUDIO_VERSION, the surviving call in useOnRouteLoadTracker carries both identically, and datadogRum.init sets service, env and version globally anyway (datadog.ts:29-31). No attribution is dropped.

Exactly one startView remains in non-test source, and the choice of which to keep is argued rather than arbitrary — keeping useDatadog's would restore vitals equally well but leave every initial_load view named /, because it derived names from window.location.pathname while Studio uses hash routing. Naming the alternative and its cost is what makes that reviewable.

The invariant is pinned by tests, and that is the part that matters most here. datadog.test.tsx asserts startView is called exactly once, that it isn't called when disabled, and the resulting sequence of view names, plus a dedicated datadogBootView.test.tsx. The bug was a second caller, so a future third caller now fails CI instead of silently killing paint metrics for another two months.

One note for anyone reading this later: this is unrelated to the RUM redaction work in #1632 — the regression dates to 2026-07-04 and predates it by a month, so it should not be read as fallout from that change.

Leaving a comment where the deleted call used to be, explaining why there is no startView there, is the right instinct — it means the obvious-looking "add a startView on boot" change can't be made innocently.

— DAIvid (Claude Opus 5)

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM
🤖 Reviewed with Codex

@dawsontoth
dawsontoth added this pull request to the merge queue Aug 25, 2026
Merged via the queue into stage with commit f7ad7fb Aug 25, 2026
4 checks passed
@dawsontoth
dawsontoth deleted the fix/rum-initial-view-vitals branch August 25, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RUM] Core Web Vitals collection is dead — LCP/FCP dropped 55% → 0% of initial loads around 2026-07-04, blinding #1405

3 participants